iT邦幫忙

2024 iThome 鐵人賽

DAY 15
0

https://ithelp.ithome.com.tw/upload/images/20240929/20168201CUY12MqzxZ.png

今天要介紹的是 Proxy 模式,這也是 GoF 在書中提及的設計模式之一,屬於結構型的設計模式,不過《JavaScript 設計模式學習手冊 第二版》這本書沒有特別介紹 Proxy 模式,這裡就整理我看 GoF 原文和網路相關資源的筆記~

情境

在大型應用程式中,有時我們須控制對某些物件的存取,例如,一個複雜物件可能需要大量的資源來運算、初始化或處理資料,或者有時我們需要限制對特定資料的存取。在這些情況下,如果直接存取或使用這些物件可能導致效能問題或安全性問題。
以 GoF 書中提到的例子來說,假如我們有個文件編輯器的應用程式,使用者可能會嵌入一些大型圖像,而這些圖像需要花費很多資源才能載入,如果每次打開文件都立即載入所有圖像,會導致文件打開時變得非常緩慢,因此需要有個方式來避免直接存取、運算這些複雜圖像物件。

問題

如何在不直接存取目標物件的狀況下,仍能有效的控制、管理或延遲對該物件的操作?如此可節省運算資源、提高應用程式效能,並避免敏感資訊被未授權者操作。

權衡

  • 存取物件時應具備彈性:對於運算或存取成本高的物件,我們希望延遲它們的初始化或存取,以節省資源。然而,有些情況下應用程式需要立即存取這些物件,因此必須在延遲初始化和即時存取間取得平衡
  • 權限控管:需要一個方法來限制對敏感物件的存取,以保障安全性,但同時也要提供足夠的便利性,讓有權限的使用者能正常存取和操作這些物件,避免過多的阻礙
  • 擴展性與穩定性:希望在不修改目標物件的情況下增加管理或延遲功能,但也要避免過多的擴展而影響系統穩定性和效能
  • 效能考量:此方法的目標是管控對物件的存取以節省系統資源並提高效能,但加入管控機制可能會帶來一定的效能成本,需要在節省資源和系統效能提升間找到合適的平衡點

解決方案

Proxy 中文可翻作代理,而代理的意思就像是一個中間人的角色,以生活化例子來說,如果某品牌廠商想找藝人合作宣傳商品,品牌方會聯絡藝人的經紀人而非直接聯絡藝人,那經紀人就扮演代理人的角色
GoF 在書中定義 Proxy 的目的是:「Provide a surrogate or placeholder for another object to control access to it.」。簡單來說我們不會直接存取和操作目標物件,而是改用一個代理來負責對目標物件的操作,這個代理可保護目標物件被直接操作或存取,也可以多一層篩選。
簡單示意圖說明沒有 Proxy 與有 Proxy 的差別:
https://ithelp.ithome.com.tw/upload/images/20240929/20168201IgX2EnB2dX.jpg
圖 1 有無 Proxy 的示意圖(資料來源:自行繪製)

現在來看個簡單範例,假設現在有個物件 user,裡面有名字、年紀、居住地等資料。

const user = {
  name: "Monica", 
  age: 24,       
  location: "Taipei", 
  interests: ["read comics", "sleep"] 
};

我們可透過屬性名稱來取出對應的值,也可以指派新的值給該屬性。

console.log(user.name);      // Monica
console.log(user.age);       // 24
console.log(user.location);  // Taipei

user.name = 'linnn';
user.age = 20;
console.log(user.name);      // linnn
console.log(user.age);       // 20

以上是一個很常見也是我們平常會存取和操作物件的方式。
但現在有個新需求,就是我們的使用者希望自己的年齡永遠 18 歲!🤣希望在每次存取 user.age 的時候都回傳 18,不管原本是多少,而如果要操作修改 age 的話,還是可以修改,但會印出訊息告訴你,該使用者永遠 18 歲👶。其餘屬性的存取和操作則保持不變。
這時我們就可以用 Proxy 來代理對 user 物件的存取和操作,那要如何實作 Proxy 呢?在 JavaScript 現有語法中已經有一個 Proxy 建構子,我們可直接呼叫 new Proxy 來建立 proxy 實例。
呼叫 Proxy 建構子時需要傳入兩個參數,第一個是 target,第二個是 handler

const myProxy = new Proxy(target, handler);

target 傳入希望代理的目標物件,以上面範例來說就是 user 物件。handler 則傳入一個物件,這物件會定義 proxy 要做哪些額外的處理來管控對目標物件的操作,以上面範例來說,就是要加上管控年齡的相關邏輯。
這個 handler 物件內可以包含的方法如: getsethassetPrototypeOf...等,完整介紹可看文件,其中我們最常使用的就是 handlergetset 方法。
get 方法會在我們要存取目標物件屬性時被觸發執行,例如要存取 target.xxx 時,proxy 就會呼叫 getget 方法會收到 3 個參數,分別為 targetpropertyreceiver

  • target : 要存取的目標物件
  • property: 要存取的屬性 (以上面舉例來說就是 xxx)
  • receiver : 通常代表 proxy 本身(代表收到這個存取需求的)

set 方法則是在我們要更改目標物件屬性時被觸發執行,例如要執行 target.xxx = 'newValue' 時,proxy 就會呼叫 setset 方法會收到 4 個參數,分別為 targetpropertyvaluereceivertargetpropertyreceiver 代表的意思和 get 的參數一樣,但 set 有多一個 value 參數,這代表的是賦予的新值,以前面舉例來說就是 'newValue'

proxy 收到存取和更改的需求時,除了在 getset 方法內做些額外處理,還有最重要的是要實作這些存取和操作(總不能告訴代理人我要做這個,結果代理人做了額外的事情,卻沒做到我實際上希望他做的事吧...),所以 proxy 需要在 handler 內實際存取或操作目標物件,按照 getset 收到的參數,我們可以在 get 方法內回傳 target[property] 來提供目標物件的屬性值,在 set 方法內執行 target[property] = value 來實際更改目標物件的屬性值。

不過通常我們會搭配使用 Reflect 來存取或操作目標物件,執行 Reflect.get(target, property, receiver) 就代表是存取目標物件的值,執行 Reflect.set(target, property, value, receiver) 則代表設定/更改目標物件的值。Reflectgetset 方法參數都和 handlergetset 方法參數一樣,照樣傳入即可。
以下是 handler 的程式碼範例,這個 handler 就可以在建立 proxy 實例時作為參數填入。

// ... 定義目標物件 target

const proxyHandler = {
    get(target, property, receiver) {
      // 一些額外處理邏輯
      console.log(`你正在存取目標物件 ${JSON.stringify(target)} 的 ${property} 屬性`);
      
      // 存取目標物件的值
      return Reflect.get(target, property, receiver); // 補充,如果沒有特別需求,也可以不傳入 receiver 參數
    }
    
    set(target, property, value, receiver) {
      // 一些額外處理邏輯
      console.log(`你正在設定目標物件 ${JSON.stringify(target)} 的 ${property} 屬性,新的值為 ${value}`);
      
     // 操作目標物件
      return Reflect.set(target, property, value, receiver); // 補充,如果沒有特別需求,也可以不傳入 receiver 參數
    }
}

const myProxy = new Proxy(target, proxyHandler);

介紹完 Proxy 基本使用方式後,回到我們的 user 來看如何加上 proxy 吧!
我們要先建立 userProxy 來負責代理對 user 的存取和操作,接著我們要存取或操作 user 時,都要透過 userProxy 來進行。

const user = {
  name: "Monica",
  age: 24,
  location: "Taipei",
  interests: ["read comics", "sleep"]
};

const userProxy = new Proxy(user, {
  get(target, prop) {
    if (prop === 'age') { // 如果要存取的屬性是年紀,就永遠回傳 18
      return 18;
    }
    return Reflect.get(target, prop); // 使用 Reflect 存取目標物件屬性
  },
  set(target, prop, value) {
    if (prop === 'age') { // 如果要修改的屬性是年紀,印出提示訊息
      console.log('該使用者永遠 18 歲👶');
    }
    return Reflect.set(target, prop, value); // 使用 Reflect 修改目標物件屬性值
  }
});

// 測試存取和修改
console.log(userProxy.age); // 18
userProxy.age = 30; // 該使用者永遠 18 歲👶
console.log(userProxy.age); // 18
console.log(userProxy.name); // Monica
userProxy.name = "linnn";
console.log(userProxy.name); // linnn

以上是一個簡單的 Proxy 應用範例,也介紹了 JavaScript 原生 Proxy 的使用方式~接著介紹 Proxy 的應用場景與案例。

應用案例

Proxy 可應用的地方非常多,例如:

延後初始化(lazy initialization)

這在 GoF 書中又稱為 virtual proxy。如果在應用程式中有個肥大、運算成本高昂的物件時,但這個物件不需要隨時都在運行/啟動狀態,只有特定狀況才需要,就可利用 Proxy 讓它在需要的時候才要初始化。開頭情境所提的文件編輯器的圖像存取就是這個例子,我們可用 proxy 來延後對複雜圖像物件的初始化。

debouncethrottle

在前端應用中常見的 debouncethrottle 都可視為 virtual proxy 的一種。
我們將原始方法用 debounce 函式包住,改呼叫 debounce 函式包住的方法,這樣 debounce 函式就可作為 proxy 來幫我們延後呼叫原始的函式。簡單 debounce 範例如下,throttle 也是應用類似的 proxy 概念。

function debounce(fn, t) {
  let timerId;

  return function (...args) {
    clearTimeout(timerId);

    timerId = setTimeout(() => {
      fn(...args);
    }, t);
  };
}
function myFunc(){
    console.log('hello!')
}
const debounceMyFunc = debounce(myFunc, 1000);

// 改呼叫 debounceMyFunc
debounceMyFunc()

控制存取(access control)

這在 GoF 書中又稱為 protection proxy。如果希望只有特定客戶端能存取目標物件時,就可使用 Proxy 來進行初步的篩選,只有符合特定條件的 Proxy 才會將請求傳遞給目標物件。

以網路服務來說,如果 client 送出請求,會先經過 proxy server,proxy 會判斷 client 的資料來決定 client 是否有權限可訪問這資源。

驗證(validation)

這也可應用在驗證(validation)上,當我們要更改物件屬性值時,proxy 可先檢查輸入值是否合法,例如如果要更改 user 的 age,那輸入的值就要是數值、不能是字串,如果是字串,那 proxy 就不會對目標物件操作。簡單示意在 proxy handler 可以這樣寫:

const proxyHandler = {
  set: (obj, prop, value) => {
    if (prop === "age" && typeof value !== "number") {
      console.log(`age 只能是數值`);
    } else {
      Reflect.set(obj, prop, value);
    }
  }
}

Immer 函式庫

此外,React 開發者應該對 Immer 函式庫不陌生,Immer 是一個用來簡化不可變(immutable)狀態操作的函式庫。它允許我們用 mutable 的方式來編寫代碼,但實際上保持狀態不可變。這讓 Immer 非常適合與 React 等需要處理 immutable 狀態的框架一起使用。
使用 Immer 時,我們會接收到一個 draft 參數,這個 draft 就是目前狀態(currentState)的代理物件(proxy)。我們可以直接修改 draft(例如:draft.age = 25),但因為我們操作的是代理物件,原始狀態並不會被直接改變。Immer 會記錄我們對 draft 的所有修改,並根據這些修改生成下一個狀態(nextState),實現 immutable 狀態的更新。
以下為 Immer 官網示意圖。
image
圖 2 Immer 運作示意圖(資料來源:Immer 官網)

遠端服務的本地執行(local execution of a remote service)

這在 GoF 書中又稱為 remote proxy。如果要操作或請求的服務位於遠端伺服器時,可由 proxy 來負責傳送請求,讓 proxy 負責與網路溝通的複雜細節,client 就不需自行處理。

proxy server

在前端應用中很常會遇到 CORS (跨來源資源共享)問題,是因為發出請求的前端和擁有資源的後端在不同源(不同 origin),因此瀏覽器會擋住請求回來的資料。而這時我們就可用 proxy server 來代理這個請求,如果原本是前端 A 要向後端 B 發出請求,就改成前端 A 向 proxy sever 發出請求,proxy server 再向後端 B 請求,接著 proxy server 收到後端 B 回覆後再傳給前端 A。
proxy sever 收到後端資料要傳給前端時,就可再加上額外邏輯處理,也就是幫前端把 response 加上 Access-Control-Allow-Origin 這個 header。

以前第一次遇到 CORS 問題在查解法時,一直看到 proxy 但都十分模糊不知道這是什麼,剛好趁這次文章有理解 proxy 的意思了XD
關於 CORS 的詳細解說推薦大家閱讀:CORS 完全手冊(一):為什麼會發生 CORS 錯誤?CORS 完全手冊(二):如何解決 CORS 問題?

imgproxy

另外,在目前工作中我接觸到 imgproxy 這服務,imgproxy 是一個處理圖片的服務,可透過 URL 參數來調整、縮放或優化圖片,imgproxy 會根據給定的參數處理圖片,並將圖片結果回傳給我們,這很方便我們生成各種大小的圖片,以適應前端不同大小的裝置。而 imgproxy 就可視為 remote proxy 的一種,因為它接收 client 端請求,處理遠端圖片並回傳結果,也簡化了 client 端的操作,讓 client 端只要發送請求即可,不需自行處理圖片。

記錄請求(logging requests)

又稱為 logging proxy,如果想要保留對目標物件的請求記錄時,就可使用 proxy,在每個請求傳遞給目標物件前先記錄請求的資訊。

快取請求結果(caching request results)

又稱為 caching proxy,如果某個請求需要很久的花費時間,就可快取這個請求結果。在 proxy 中儲存相同請求參數的請求結果,如果快取中有儲存該結果,就直接回傳快取的;如果沒有,才發出真正的請求。在 用 JavaScript 玩轉設計模式 | 替你處理行為的 Proxy Pattern(代理者模式) 有提及這種緩存 API 請求的程式碼。

CDN

CDN(Content Delivery Network,內容傳遞網路)其實也是應用了 Proxy 的概念,他快取了請求結果,避免多次向遙遠的伺服器請求。

useMemo

而在 React 中,useMemo 這個 hook 其實也可視為快取代理,因為它快取了複雜運算的結果,只有在 dependencies 不同時才會重新運算。

優點

以 Proxy 作為解決方案優點如下:

  • 解耦合:客戶端不會直接存取目標物件,增加應用的彈性,降低偶合度
  • 增加對物件的控制:可在與物件互動時新增功能,例如:驗證、記錄資訊、除錯、快取或延遲初始化等

缺點

以 Proxy 作為解決方案缺點如下:

  • 增加程式碼複雜度:程式碼會變長、變複雜,過度引入會讓系統較難管理和維護
  • 潛在的單點故障:proxy 可能成為系統的單點故障,如果代理出錯可能會導致無法順利存取和操作目標物件,要額外注意其穩定性和錯誤處理邏輯
  • 效能問題:過度使用 proxy 處理額外邏輯可能會影響應用程式效能,也可能會增加操作或存取目標物件的時間(因為多一層 proxy 的邏輯要執行)

Reference


上一篇
[Day 14] Command 模式
下一篇
[Day 16] MVC 模式
系列文
30天的 JavaScript 設計模式之旅30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言